Home

Rust is a Half-Baked Language

Rust is my programming language of choice for almost all projects I am working on and has been for about three years. This however, doesn't mean I like it. So, while I wait for the situation regarding many of the new systems languages (such as Odin, Zig, or Jai) to settle before making a decision about where I want to invest my time and energy long term, I decided I would take the opportunity to vent about a particular style of problem that comes up frequently in Rust. In particular, many of the language's features feel half-baked, incomplete, or poorly thought out.

There are many open design questions or incomplete features that feel like the kind of thing one would expect in a pre 1.0.0 language which is still finding its feet.

To be clear, these are not in my opinion the biggest issue with Rust as a language, and my explaining of them will give no indication as to why I choose to use Rust anyway. However I think drawing more attention to these can help with the design of future languages to learn from the mistakes of a post 1.0.0 language that still feels strangely unfinished.

As I am writing this, I am just trying to get out some of the things that have been most frustrating to me recently, but I will add more sections to this article as things arise in my day to day life that cause me frustration.

Course Grained Lifetime Management

This is a bit of an overarching category of issues, where Rust is simply not keeping track of enough information with respect to lifetime management.

Tracking References to Individual Fields

Consider the following Rust program.

struct Foo {
    a: i8,
    b: u8,
}

impl Foo {
    fn get_a_mut(&mut self) -> &mut i8 {
        &mut self.a
    }
}

fn main() {
    let mut foo = Foo { a: 0, b: 0 };
    let a = foo.get_a_mut();
    let b = &mut foo.b;
    println!("{}", a);
    println!("{}", b);
}

This code fails to compile, with the following error.

error[E0499]: cannot borrow `foo.b` as mutable more than once at a time
  --> src/main.rs:15:13
   |
14 |     let a = foo.get_a_mut();
   |             --- first mutable borrow occurs here
15 |     let b = &mut foo.b;
   |             ^^^^^^^^^^ second mutable borrow occurs here
16 |     println!("{}", a);
   |                    - first borrow later used here

If we apply remove the call to get_a_mut and instead replace that line with what the function is actually doing (i.e. manually inline the function), we get a working program.

struct Foo {
    a: i8,
    b: u8,
}

impl Foo {
    fn get_a_mut(&mut self) -> &mut i8 {
        &mut self.a
    }
}

fn main() {
    let mut foo = Foo { a: 0, b: 0 };
    let a = &mut foo.a;
    let b = &mut foo.b;
    println!("{}", a);
    println!("{}", b);
}

In the fixed example, the following two lines of code

let a = &mut foo.a;
let b = &mut foo.b;

are able to be identified as referring to different fields of the struct, and thus the mutable references do not overlap, much like the behaviour of

let Foo {
  a, b,
} = &mut foo;

So what is going on here? It would appear as though once we do the operation within the function, this extra lifetime information is lost. That is, the lifetime information given by the type signature of the function

fn get_a_mut(&mut self) -> &mut i8;

does not include within it the fact that the returned reference only refers to the field a within self.

While this example is somewhat contrived, a real world scenario where I have run into this problem is when using a field of type Option<T> and wishing to return a value of type &mut T, by performing a form of unwrapping with specific behaviour for how to handle the None case. For this reason, it actually makes sense to give it its own function rather than just perform the operation inline.

Internal Mutation Before Returning Immutable Reference

Keeping the example similar to the previous, now we have both fields of type u8, and the function we are examining, get_a_and_update_b returns an immutable reference to a, after having set b to have its value.

struct Foo {
    a: u8,
    b: u8,
}

impl Foo {
    fn get_a_and_update_b(&mut self) -> &u8 {
        self.b = self.a;
        &self.a
    }
}

fn main() {
    let mut foo = Foo { a: 0, b: 0 };
    let a = foo.get_a_and_update_b();
    let b = &foo.b;
    println!("{}", a);
    println!("{}", b);
}

Once again, this code does not compile. This is because, even though get_a_and_update_b is done mutating foo, and there is no mutable reference left to be found, the compiler still considers a to be the first mutable borrow which interferes with the immutable borrow of b. The particular error given by the compiler is shown below.

error[E0502]: cannot borrow `foo.b` as immutable because it is also borrowed as mutable
  --> src/main.rs:16:13
   |
15 |     let a = foo.get_a_and_update_b();
   |             --- mutable borrow occurs here
16 |     let b = &foo.b;
   |             ^^^^^^ immutable borrow occurs here
17 |     println!("{}", a);
   |                    - mutable borrow later used here

The compiler clearly states the use of a is a mutable borrow being used, but a has type &u8.

Most recently this occured for me when working on an abstraction on top of a file of markup which looked somewhat like the following.

struct File {
  ast: Ast,
  content: String,
  path: std::path::PathBuf,
}

I wanted a method on File with a signature as follows.

fn update(&mut self, new: String) -> &Ast;

This function should replace the content with new, reparsing the file and storing it in ast, and then returning a reference to the updated Ast. However now this reference to ast is treated like a mutable reference, even though it isn't, meaning I cannot read the path and then access the reference I have to the ast.

Self References Which Aren't

This is somewhat a symptom of the first problem, and the way that the bottom level abstractions on memory allocation work. Consider the following simple function.

fn reference(array: Vec<u8>) -> (Vec<u8>, &u8) {
    let first = &array[0];
    (array, first)
}

This code will not compile, and gives the following error (among others, although this one is the most insightful).

error[E0515]: cannot return value referencing function parameter `array`
 --> src/main.rs:4:5
  |
3 |     let first = &array[0];
  |                  ----- `array` is borrowed here
4 |     (array, first)
  |     ^^^^^^^^^^^^^^ returns a value referencing data owned by the current function

Of course, Rust is trying to prevent dangling references; we cannot reference something owned by the local function, because that thing will be dropped at the end of scope and the reference will be invalid. In cases where the value is not dropped but rather moved, like the above, moving still results in the new data being in a different memory location, as the return value will be copied to a higher stack frame after returning.

However, in this case the reference is not to the data on the stack held by the struct Vec, it is data on the heap that most certainly does not move when returning.

Again, a contrived example, however I don't think it's a stretch to imagine that, as in the previous example, one may want to store references to substrings of a file's content on the AST, and doing so makes it impossible to store the two in a struct together.

Const Associated Items in Traits

In a trait, you can create an associated type as a way to associate a particular type to each implementation, which the trait is not generic over. This is used for the output type in the Index trait for instance.

With the introduction of const, one would hope that you could do the same with a const item, such as a usize. This is fine, but then that value cannot be referred to in the type signature of other functions. As such, the following does not compile.

trait Codec {
    const N: usize;

    fn encode(self) -> [u8; Self::N];
    fn decode(encoded: [u8; Self::N]) -> Self;
}

For Loops (and similar) in Const

const is an extremely half-baked feature in general. For instance, for loops cannot be used within const environments, so the following will not compile.

const TOTAL: u32 = {
    let mut total = 0;
    for i in 0..10 {
        total += 1;
    }
    total
};

This is despite the fact that the while loop equivalent does.

const TOTAL: u32 = {
    let mut total = 0;
    let mut i = 0;
    while i < 10 {
        total += 1;
        i += 1;
    }
    total
};

This is a consequence of the use of an iterator in the for loop, and the fact that the iterator functions are not const. However, one cannot mark a function in a trait as const under any circumstances, either in the implementation or definition. In the case of other function colouring, such as async and unsafe, if the trait definition has the modifier, so too must the implementation. The colouring is much like a part of the type signature, and thinking about it like this, normal functions are a subtype of unsafe functions. This works in for unsafe in general, with the following compiling just fine.

fn main() {
    a(x)
}

fn x() { }

fn a(_: unsafe fn() -> ()) { }

But none of this works properly for const.

if let _ and _

if let is a half-baked feature due to the lack of support for any additional boolean conditions. This makes the use of the keyword if really confusing. For instance, the following code does not compile.

let mut map = std::collections::HashMap::from(
    [('a', 0), ('b', 1), ('c', 2),]
);

if let Some(value) = map.get(&'a') && map.len() == 2 {
    println!("Hello");
}